From: Matteo Collina Date: Sun, 19 Apr 2026 14:48:43 +0000 (+0200) Subject: http2: cap originSet size to prevent unbounded memory growth X-Git-Tag: archive/raspbian/18.20.4+dfsg-1_deb12u3+rpi1^2~12 X-Git-Url: https://dgit.raspbian.org/%22http://www.example.com/cgi/%22/%22http:/www.example.com/cgi/%22?a=commitdiff_plain;h=2aacec2ff42b32c52a5cdc224b6d92fa6297542e;p=nodejs.git http2: cap originSet size to prevent unbounded memory growth A malicious HTTP/2 server can send repeated ORIGIN frames with unique origins, causing unbounded growth of the client-side originSet for the lifetime of the session. Cap the set at 128 entries; once full, new origins from ORIGIN frames are silently dropped. Ref: https://hackerone.com/reports/3676863 PR-URL: https://github.com/nodejs-private/node-private/pull/855 Reviewed-By: Antoine du Hamel CVE-ID: CVE-2026-48619 Refs: https://hackerone.com/reports/3676863 origin: backport, https://github.com/nodejs/node/commit/c79968e108002c2394bdb9e9cefb2c8c8cc202f8 Gbp-Pq: Topic sec Gbp-Pq: Name CVE-2026-48619.patch --- diff --git a/doc/api/errors.md b/doc/api/errors.md index c657814b0..3a07f4a45 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1724,6 +1724,17 @@ added: v15.14.0 The limit of acceptable invalid HTTP/2 protocol frames sent by the peer, as specified through the `maxSessionInvalidFrames` option, has been exceeded. + + +### `ERR_HTTP2_TOO_MANY_ORIGINS` + + + +The number of uniq origin sent by the server has exceeded the value defined in +`options.maxOriginSetSize`. + ### `ERR_HTTP2_TRAILERS_ALREADY_SENT` diff --git a/doc/api/http2.md b/doc/api/http2.md index 518990a2e..705644d5c 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -2719,6 +2719,8 @@ changes: This is similar to [`server.maxHeadersCount`][] or [`request.maxHeadersCount`][] in the `node:http` module. The minimum value is `1`. **Default:** `128`. + * `maxOriginSetSize` {number} Sets the maximum number of uniq origin the sever + can send via ORIGIN frames. **Default:** `128`. * `maxOutstandingPings` {number} Sets the maximum number of outstanding, unacknowledged pings. **Default:** `10`. * `maxReservedRemoteStreams` {number} Sets the maximum number of reserved push diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 6e1974b1e..9ad8d7695 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1154,6 +1154,8 @@ E('ERR_HTTP2_STREAM_ERROR', 'Stream closed with error code %s', Error); E('ERR_HTTP2_STREAM_SELF_DEPENDENCY', 'A stream cannot depend on itself', Error); E('ERR_HTTP2_TOO_MANY_INVALID_FRAMES', 'Too many invalid HTTP/2 frames', Error); +E('ERR_HTTP2_TOO_MANY_ORIGINS', + 'The server sent more ORIGIN frames than the allowed number of %s', Error); E('ERR_HTTP2_TRAILERS_ALREADY_SENT', 'Trailing headers have already been sent', Error); E('ERR_HTTP2_TRAILERS_NOT_READY', diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 38844d30f..9ba39d49d 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -105,6 +105,7 @@ const { ERR_HTTP2_STREAM_CANCEL, ERR_HTTP2_STREAM_ERROR, ERR_HTTP2_STREAM_SELF_DEPENDENCY, + ERR_HTTP2_TOO_MANY_ORIGINS, ERR_HTTP2_TRAILERS_ALREADY_SENT, ERR_HTTP2_TRAILERS_NOT_READY, ERR_HTTP2_UNSUPPORTED_PROTOCOL, @@ -235,6 +236,7 @@ const kInit = Symbol('init'); const kInfoHeaders = Symbol('sent-info-headers'); const kLocalSettings = Symbol('local-settings'); const kNativeFields = Symbol('kNativeFields'); +const kMaxOriginSetSize = Symbol('max-ORIGIN-set-size'); const kOptions = Symbol('options'); const kOwner = owner_symbol; const kOrigin = Symbol('origin'); @@ -661,8 +663,13 @@ function onOrigin(origins) { if (!session.encrypted || session.destroyed) return undefined; const originSet = initOriginSet(session); - for (let n = 0; n < origins.length; n++) + for (let n = 0; n < origins.length; n++) { + if (originSet.size >= session[kMaxOriginSetSize]) { + session.destroy(new ERR_HTTP2_TOO_MANY_ORIGINS(session[kMaxOriginSetSize])); + return; + } originSet.add(origins[n]); + } session.emit('origin', origins); } @@ -3276,6 +3283,13 @@ function connect(authority, options, listener) { assertIsObject(options, 'options'); options = { ...options }; + let { maxOriginSetSize } = options; + if (maxOriginSetSize != null) { + validateNumber(maxOriginSetSize, 'options.maxOriginSetSize', 0); + } else { + maxOriginSetSize = 128; + } + if (typeof authority === 'string') authority = new URL(authority); @@ -3315,6 +3329,7 @@ function connect(authority, options, listener) { session[kAuthority] = `${options.servername || host}:${port}`; session[kProtocol] = protocol; + session[kMaxOriginSetSize] = maxOriginSetSize; if (typeof listener === 'function') session.once('connect', listener); diff --git a/test/parallel/test-http2-origin-set-max-size.mjs b/test/parallel/test-http2-origin-set-max-size.mjs new file mode 100644 index 000000000..08bdf1ed7 --- /dev/null +++ b/test/parallel/test-http2-origin-set-max-size.mjs @@ -0,0 +1,110 @@ +import { + expectsError, + hasCrypto, + mustCall, + mustNotCall, + mustSucceed, + skip, +} from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; + +if (!hasCrypto) + skip('missing crypto'); + +const { + createSecureServer, + connect, +} = await import('node:http2'); + +const key = fixtures.readKey('agent8-key.pem', 'binary'); +const cert = fixtures.readKey('agent8-cert.pem', 'binary'); +const ca = fixtures.readKey('fake-startcom-root-cert.pem', 'binary'); + +const server = createSecureServer({ key, cert }); +server.on('stream', (stream) => { + stream.respond(); + stream.end('ok'); +}); +server.on('session', (session) => { + let i = 0; + const timer = setInterval(() => { + try { + session.origin(...Array.from({ length: 10 }, () => `https://o${i++}.example.com`)); + } catch { + clearInterval(timer); + } + }, 10); + + session.on('close', () => { + clearInterval(timer); + }); +}); + +await new Promise((resolve) => server.listen(0, resolve)); + +// Test fantasist values +[Symbol(), '0', 1n, {}, [], true, false, /s/, () => {}].forEach((maxOriginSetSize) => { + assert.throws( + () => connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); +}); +[NaN, -1].forEach((maxOriginSetSize) => { + assert.throws( + () => connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }), + { code: 'ERR_OUT_OF_RANGE' }, + ); +}); + +await new Promise((resolve) => server.getConnections(mustSucceed((count) => { + assert.strictEqual(count, 0); + resolve(); +}))); + +// Test default value +await new Promise((resolve) => { + const client = connect(`https://localhost:${server.address().port}`, { ca }); + + client.on('origin', mustCall(12)); // Default value is 128, the first 12 frames should pass, the 13th one should error + client.on('error', expectsError({ + code: 'ERR_HTTP2_TOO_MANY_ORIGINS', + })); + client.on('goaway', mustNotCall()); + client.on('close', resolve); + + client.request().resume(); +}); + +// Test non-default values +await Promise.all([-0, 9, 1.5].map((maxOriginSetSize) => new Promise((resolve) => { + const client = connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }); + + client.on('origin', mustNotCall()); // The server send 10 origins on the first frame, that's already too many. + client.on('error', expectsError({ + code: 'ERR_HTTP2_TOO_MANY_ORIGINS', + })); + client.on('goaway', mustNotCall()); + client.on('close', resolve); + + client.request().resume(); +}))); + + +// Test values higher than the default value +await Promise.all([512, Infinity].map((maxOriginSetSize) => new Promise((resolve) => { + const client = connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }); + + client.on('origin', mustCall(() => { + if (client.originSet.length > 128) { + client.destroy(); + } + }, 13)); + client.on('error', mustNotCall()); + client.on('goaway', mustNotCall()); + client.on('close', resolve); + + client.request().resume(); +}))); + +server.close();